Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit ce1a9fca2b7beae317db6f59d76ebb652ca332f6


Parents : 2030791
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-29T15:24:22-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-29T20:24:22Z

fix(firmware): repair nRF USB firmware update and post-update reconnect (#6018)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt
index a63d962296..774be8eef3 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/ProbeTableProvider.kt
@@ -30,9 +30,11 @@ import org.koin.core.annotation.Single
@Single
class ProbeTableProvider {
fun get(): ProbeTable = UsbSerialProber.getDefaultProbeTable().apply {
- // RAK 4631:
+ // RAK 4631 (0x239A / 0x8029):
addProduct(9114, 32809, CdcAcmSerialDriver::class.java)
// LilyGo TBeam v1.1:
addProduct(6790, 21972, CdcAcmSerialDriver::class.java)
+ // Elecrow ThinkNode M3 / M4 / M6 and LilyGo T-Echo (0x239A / 0x4405) β€” native nRF52840 USB CDC:
+ addProduct(9114, 17413, CdcAcmSerialDriver::class.java)
}
}

diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index 30a2928a88..84ff5f9ce4 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -54,7 +54,17 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
+ // Key by a stable identity, NOT the raw deviceList key (the /dev/bus/usb/BBB/DDD path). Android
+ // reassigns that path on every re-enumeration β€” each reboot or replug bumps it (…/002 β†’ …/008) β€” so a
+ // path-keyed map can't recognise the same physical node across a firmware-update reboot, and the
+ // auto-reconnect in SharedRadioInterfaceService (which matches the saved address against these keys)
+ // never re-arms. usbSerialStableKey() survives re-enumeration.
+ buildMap {
+ serialDevices.values.forEach { device ->
+ val driver = serialProber.probeDevice(device) ?: return@forEach
+ put(driver.device.usbSerialStableKey(), driver)
+ }
+ }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -90,3 +100,18 @@ class UsbRepository(
_serialDevices.emit(devices)
}
}
+
+/**
+ * Stable identity for a USB serial device that survives re-enumeration.
+ *
+ * Uses vendor + product id, which is permission-free and identical before and after a reboot/replug (and before vs
+ * after the firmware-update DFU bounce, since the app firmware re-enumerates with the same VID/PID). Deliberately does
+ * NOT use [UsbDevice.getSerialNumber] β€” that requires an active USB permission grant, which is exactly what is missing
+ * immediately after a re-enumeration, so a serial-based key would read back null mid-recovery and break the very
+ * reconnect this is meant to enable.
+ *
+ * ponytail: vendor:product collides if two *identical* boards are attached at once (last one wins in the map). Append
+ * getSerialNumber() β€” gated on usbManager.hasPermission(device) β€” only if multi-identical-device support is ever
+ * needed.
+ */
+internal fun UsbDevice.usbSerialStableKey(): String = "$vendorId-$productId"

diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
index f330d36339..ddae634e4d 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
@@ -76,14 +76,16 @@ class AndroidGetDiscoveredDevicesUseCase(
val usbDevicesFlow =
usbRepository.serialDevices.map { usb ->
- usb.map { (_, d) ->
+ usb.map { (stableKey, d) ->
DeviceListEntry.Usb(
usbData = AndroidUsbDeviceData(d),
name = d.device.deviceName,
+ // Address must be the stable map key, not the device path: the path (deviceName) is reassigned
+ // on every re-enumeration, so a path-based saved address can't survive a reboot/replug.
fullAddress =
radioInterfaceService.toInterfaceAddress(
org.meshtastic.core.model.InterfaceId.SERIAL,
- d.device.deviceName,
+ stableKey,
),
bonded = usbManagerLazy.value.hasPermission(d.device),
)

diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 98681d861d..3e43325c5d 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
@@ -248,6 +249,11 @@ class FirmwareUpdateViewModel(
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
+ // USB/UF2 path intentionally pauses here: the UI launches the file picker and
+ // saveDfuFile() resumes the flow. Leave the state intact (tempFirmwareFile holds
+ // the artifact for cleanup after the copy completes).
+ is FirmwareUpdateState.AwaitingFileSave -> Unit
+
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
@@ -337,6 +343,9 @@ class FirmwareUpdateViewModel(
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
+ // USB/UF2 path pauses here for the user to pick a save location; saveDfuFile() resumes it.
+ is FirmwareUpdateState.AwaitingFileSave -> Unit
+
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
@@ -367,10 +376,21 @@ class FirmwareUpdateViewModel(
private suspend fun verifyUpdateResult(address: String?, wasLowSpeedTransfer: Boolean = false) {
_state.value = FirmwareUpdateState.Verifying
- // Trigger a fresh connection attempt by MeshService using the original prefixed address
+ // Trigger a fresh connection attempt by MeshService using the original prefixed address.
+ //
+ // For USB/serial, do NOT force this: a USB node re-enumerates several times through the bootloader over
+ // ~20s, so a one-shot setDeviceAddress fires into an enumeration gap, fails ("Serial device not found"),
+ // and lands the transport in Disconnected β€” which preempts the dedicated USB auto-recovery in
+ // SharedRadioInterfaceService (observeUsbRecoveryTriggers), since that only arms from DeviceSleep. Leaving
+ // the transport in DeviceSleep lets that recovery reconnect the moment the device re-attaches on its new
+ // (stable-keyed) address. BLE/TCP have no such hot-plug recovery, so they still need the explicit re-address.
address?.let { fullAddr ->
- Logger.i { "Post-update: Requesting MeshService to reconnect to $fullAddr" }
- radioController.setDeviceAddress(fullAddr)
+ if (radioPrefs.isSerial()) {
+ Logger.i { "Post-update: leaving USB reconnect to USB auto-recovery for ${fullAddr.anonymize}" }
+ } else {
+ Logger.i { "Post-update: Requesting MeshService to reconnect to ${fullAddr.anonymize}" }
+ radioController.setDeviceAddress(fullAddr)
+ }
}
// Wait for device to reconnect and settle

diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index 241cce4dea..422ec0dfb2 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -292,6 +292,68 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
}
+ @Test
+ fun `startUpdate keeps AwaitingFileSave state for USB path`() = runTest {
+ // Regression: the UF2/USB flow ends at AwaitingFileSave β€” a deliberate pause for the file picker, not a
+ // missing terminal state. startUpdate() must leave it intact; previously it fell into the else branch and
+ // clobbered it to Error, so tapping "Update via USB File Transfer" always failed.
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ val ready = viewModel.state.value
+ assertIs<FirmwareUpdateState.Ready>(ready)
+ assertIs<FirmwareUpdateMethod.Usb>(ready.updateMethod)
+
+ val artifact =
+ FirmwareArtifact(
+ uri = CommonUri.parse("file:///tmp/firmware.uf2"),
+ fileName = "firmware.uf2",
+ isTemporary = true,
+ )
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.AwaitingFileSave(artifact, "firmware.uf2"))
+ artifact
+ }
+
+ viewModel.startUpdate()
+ advanceUntilIdle()
+
+ assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
+ }
+
+ @Test
+ fun `startUpdateFromFile keeps AwaitingFileSave state for USB path`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ assertIs<FirmwareUpdateMethod.Usb>((viewModel.state.value as FirmwareUpdateState.Ready).updateMethod)
+
+ val artifact =
+ FirmwareArtifact(
+ uri = CommonUri.parse("file:///tmp/extracted.uf2"),
+ fileName = "extracted.uf2",
+ isTemporary = true,
+ )
+ everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns artifact
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.AwaitingFileSave(artifact, "extracted.uf2"))
+ artifact
+ }
+
+ viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
+ advanceUntilIdle()
+
+ assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
+ }
+
@Test
fun `startUpdateFromFile cleans up on manager error state`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")

Served by rngit 1.5.2 - Generated in 0.15s